kairo-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. image-service/main.py +178 -0
  2. infra/chat/app/main.py +84 -0
  3. kairo/backend/__init__.py +0 -0
  4. kairo/backend/api/__init__.py +0 -0
  5. kairo/backend/api/admin/__init__.py +23 -0
  6. kairo/backend/api/admin/audit.py +54 -0
  7. kairo/backend/api/admin/content.py +142 -0
  8. kairo/backend/api/admin/incidents.py +148 -0
  9. kairo/backend/api/admin/stats.py +125 -0
  10. kairo/backend/api/admin/system.py +87 -0
  11. kairo/backend/api/admin/users.py +279 -0
  12. kairo/backend/api/agents.py +94 -0
  13. kairo/backend/api/api_keys.py +85 -0
  14. kairo/backend/api/auth.py +116 -0
  15. kairo/backend/api/billing.py +41 -0
  16. kairo/backend/api/chat.py +72 -0
  17. kairo/backend/api/conversations.py +125 -0
  18. kairo/backend/api/device_auth.py +100 -0
  19. kairo/backend/api/files.py +83 -0
  20. kairo/backend/api/health.py +36 -0
  21. kairo/backend/api/images.py +80 -0
  22. kairo/backend/api/openai_compat.py +225 -0
  23. kairo/backend/api/projects.py +102 -0
  24. kairo/backend/api/usage.py +32 -0
  25. kairo/backend/api/webhooks.py +79 -0
  26. kairo/backend/app.py +297 -0
  27. kairo/backend/config.py +179 -0
  28. kairo/backend/core/__init__.py +0 -0
  29. kairo/backend/core/admin_auth.py +24 -0
  30. kairo/backend/core/api_key_auth.py +55 -0
  31. kairo/backend/core/database.py +28 -0
  32. kairo/backend/core/dependencies.py +70 -0
  33. kairo/backend/core/logging.py +23 -0
  34. kairo/backend/core/rate_limit.py +73 -0
  35. kairo/backend/core/security.py +29 -0
  36. kairo/backend/models/__init__.py +19 -0
  37. kairo/backend/models/agent.py +30 -0
  38. kairo/backend/models/api_key.py +25 -0
  39. kairo/backend/models/api_usage.py +29 -0
  40. kairo/backend/models/audit_log.py +26 -0
  41. kairo/backend/models/conversation.py +48 -0
  42. kairo/backend/models/device_code.py +30 -0
  43. kairo/backend/models/feature_flag.py +21 -0
  44. kairo/backend/models/image_generation.py +24 -0
  45. kairo/backend/models/incident.py +28 -0
  46. kairo/backend/models/project.py +28 -0
  47. kairo/backend/models/uptime_record.py +24 -0
  48. kairo/backend/models/usage.py +24 -0
  49. kairo/backend/models/user.py +49 -0
  50. kairo/backend/schemas/__init__.py +0 -0
  51. kairo/backend/schemas/admin/__init__.py +0 -0
  52. kairo/backend/schemas/admin/audit.py +28 -0
  53. kairo/backend/schemas/admin/content.py +53 -0
  54. kairo/backend/schemas/admin/stats.py +77 -0
  55. kairo/backend/schemas/admin/system.py +44 -0
  56. kairo/backend/schemas/admin/users.py +48 -0
  57. kairo/backend/schemas/agent.py +42 -0
  58. kairo/backend/schemas/api_key.py +30 -0
  59. kairo/backend/schemas/auth.py +57 -0
  60. kairo/backend/schemas/chat.py +26 -0
  61. kairo/backend/schemas/conversation.py +39 -0
  62. kairo/backend/schemas/device_auth.py +40 -0
  63. kairo/backend/schemas/image.py +15 -0
  64. kairo/backend/schemas/openai_compat.py +76 -0
  65. kairo/backend/schemas/project.py +21 -0
  66. kairo/backend/schemas/status.py +81 -0
  67. kairo/backend/schemas/usage.py +15 -0
  68. kairo/backend/services/__init__.py +0 -0
  69. kairo/backend/services/admin/__init__.py +0 -0
  70. kairo/backend/services/admin/audit_service.py +78 -0
  71. kairo/backend/services/admin/content_service.py +119 -0
  72. kairo/backend/services/admin/incident_service.py +94 -0
  73. kairo/backend/services/admin/stats_service.py +281 -0
  74. kairo/backend/services/admin/system_service.py +126 -0
  75. kairo/backend/services/admin/user_service.py +157 -0
  76. kairo/backend/services/agent_service.py +107 -0
  77. kairo/backend/services/api_key_service.py +66 -0
  78. kairo/backend/services/api_usage_service.py +126 -0
  79. kairo/backend/services/auth_service.py +101 -0
  80. kairo/backend/services/chat_service.py +501 -0
  81. kairo/backend/services/conversation_service.py +264 -0
  82. kairo/backend/services/device_auth_service.py +193 -0
  83. kairo/backend/services/email_service.py +55 -0
  84. kairo/backend/services/image_service.py +181 -0
  85. kairo/backend/services/llm_service.py +186 -0
  86. kairo/backend/services/project_service.py +109 -0
  87. kairo/backend/services/status_service.py +167 -0
  88. kairo/backend/services/stripe_service.py +78 -0
  89. kairo/backend/services/usage_service.py +150 -0
  90. kairo/backend/services/web_search_service.py +96 -0
  91. kairo/migrations/env.py +60 -0
  92. kairo/migrations/versions/001_initial.py +55 -0
  93. kairo/migrations/versions/002_usage_tracking_and_indexes.py +66 -0
  94. kairo/migrations/versions/003_username_to_email.py +21 -0
  95. kairo/migrations/versions/004_add_plans_and_verification.py +67 -0
  96. kairo/migrations/versions/005_add_projects.py +52 -0
  97. kairo/migrations/versions/006_add_image_generation.py +63 -0
  98. kairo/migrations/versions/007_add_admin_portal.py +107 -0
  99. kairo/migrations/versions/008_add_device_code_auth.py +76 -0
  100. kairo/migrations/versions/009_add_status_page.py +65 -0
  101. kairo/tools/extract_claude_data.py +465 -0
  102. kairo/tools/filter_claude_data.py +303 -0
  103. kairo/tools/generate_curated_data.py +157 -0
  104. kairo/tools/mix_training_data.py +295 -0
  105. kairo_code/__init__.py +3 -0
  106. kairo_code/agents/__init__.py +25 -0
  107. kairo_code/agents/architect.py +98 -0
  108. kairo_code/agents/audit.py +100 -0
  109. kairo_code/agents/base.py +463 -0
  110. kairo_code/agents/coder.py +155 -0
  111. kairo_code/agents/database.py +77 -0
  112. kairo_code/agents/docs.py +88 -0
  113. kairo_code/agents/explorer.py +62 -0
  114. kairo_code/agents/guardian.py +80 -0
  115. kairo_code/agents/planner.py +66 -0
  116. kairo_code/agents/reviewer.py +91 -0
  117. kairo_code/agents/security.py +94 -0
  118. kairo_code/agents/terraform.py +88 -0
  119. kairo_code/agents/testing.py +97 -0
  120. kairo_code/agents/uiux.py +88 -0
  121. kairo_code/auth.py +232 -0
  122. kairo_code/config.py +172 -0
  123. kairo_code/conversation.py +173 -0
  124. kairo_code/heartbeat.py +63 -0
  125. kairo_code/llm.py +291 -0
  126. kairo_code/logging_config.py +156 -0
  127. kairo_code/main.py +818 -0
  128. kairo_code/router.py +217 -0
  129. kairo_code/sandbox.py +248 -0
  130. kairo_code/settings.py +183 -0
  131. kairo_code/tools/__init__.py +51 -0
  132. kairo_code/tools/analysis.py +509 -0
  133. kairo_code/tools/base.py +417 -0
  134. kairo_code/tools/code.py +58 -0
  135. kairo_code/tools/definitions.py +617 -0
  136. kairo_code/tools/files.py +315 -0
  137. kairo_code/tools/review.py +390 -0
  138. kairo_code/tools/search.py +185 -0
  139. kairo_code/ui.py +418 -0
  140. kairo_code-0.1.0.dist-info/METADATA +13 -0
  141. kairo_code-0.1.0.dist-info/RECORD +144 -0
  142. kairo_code-0.1.0.dist-info/WHEEL +5 -0
  143. kairo_code-0.1.0.dist-info/entry_points.txt +2 -0
  144. kairo_code-0.1.0.dist-info/top_level.txt +4 -0
image-service/main.py ADDED
@@ -0,0 +1,178 @@
1
+ """Kairo Image Generation Service — FLUX.1-dev on GPU (text2img + img2img)."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from io import BytesIO
7
+
8
+ import torch
9
+ from diffusers import FluxPipeline, FluxImg2ImgPipeline
10
+ from fastapi import FastAPI, Depends, HTTPException, Header, Form, UploadFile, File
11
+ from fastapi.responses import StreamingResponse
12
+ from PIL import Image
13
+ from pydantic import BaseModel, Field
14
+
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger(__name__)
17
+
18
+ app = FastAPI(title="Kairo Image Service")
19
+
20
+ API_KEY = os.environ.get("IMAGE_API_KEY", "")
21
+ pipe: FluxPipeline | None = None
22
+ img2img_pipe: FluxImg2ImgPipeline | None = None
23
+
24
+
25
+ # ── Schemas ──────────────────────────────────────────────────────────
26
+
27
+ class GenerateRequest(BaseModel):
28
+ prompt: str = Field(..., min_length=1, max_length=2000)
29
+ width: int = Field(default=1024, ge=256, le=2048)
30
+ height: int = Field(default=1024, ge=256, le=2048)
31
+ num_inference_steps: int = Field(default=25, ge=1, le=50)
32
+ seed: int | None = None
33
+
34
+
35
+ # ── Auth ─────────────────────────────────────────────────────────────
36
+
37
+ async def verify_api_key(authorization: str = Header(default="")):
38
+ if API_KEY and authorization != f"Bearer {API_KEY}":
39
+ raise HTTPException(status_code=401, detail="Invalid API key")
40
+
41
+
42
+ # ── Lifecycle ────────────────────────────────────────────────────────
43
+
44
+ @app.on_event("startup")
45
+ async def load_model():
46
+ global pipe, img2img_pipe
47
+ logger.info("Loading FLUX.1-dev with sequential CPU offloading...")
48
+ pipe = FluxPipeline.from_pretrained(
49
+ "black-forest-labs/FLUX.1-dev",
50
+ torch_dtype=torch.bfloat16,
51
+ )
52
+ pipe.enable_sequential_cpu_offload()
53
+
54
+ # Create img2img pipeline sharing all model components (zero extra VRAM)
55
+ img2img_pipe = FluxImg2ImgPipeline(
56
+ transformer=pipe.transformer,
57
+ scheduler=pipe.scheduler,
58
+ vae=pipe.vae,
59
+ text_encoder=pipe.text_encoder,
60
+ text_encoder_2=pipe.text_encoder_2,
61
+ tokenizer=pipe.tokenizer,
62
+ tokenizer_2=pipe.tokenizer_2,
63
+ )
64
+
65
+ logger.info("FLUX.1-dev loaded (text2img + img2img ready)")
66
+
67
+
68
+ # ── Helpers ──────────────────────────────────────────────────────────
69
+
70
+ def _snap_to_multiple(value: int, multiple: int = 16) -> int:
71
+ """Round to nearest multiple (required by VAE)."""
72
+ return max(multiple, round(value / multiple) * multiple)
73
+
74
+
75
+ def _run_pipeline(prompt: str, width: int, height: int, steps: int, seed: int | None):
76
+ """Run text2img pipeline synchronously (called via asyncio.to_thread)."""
77
+ generator = (
78
+ torch.Generator("cuda").manual_seed(seed)
79
+ if seed is not None
80
+ else None
81
+ )
82
+ try:
83
+ image = pipe(
84
+ prompt=prompt,
85
+ width=_snap_to_multiple(width),
86
+ height=_snap_to_multiple(height),
87
+ num_inference_steps=steps,
88
+ generator=generator,
89
+ ).images[0]
90
+ buf = BytesIO()
91
+ image.save(buf, format="PNG")
92
+ buf.seek(0)
93
+ return buf
94
+ finally:
95
+ torch.cuda.empty_cache()
96
+
97
+
98
+ def _run_img2img_pipeline(
99
+ image: Image.Image,
100
+ prompt: str,
101
+ strength: float,
102
+ steps: int,
103
+ seed: int | None,
104
+ ):
105
+ """Run img2img pipeline synchronously (called via asyncio.to_thread)."""
106
+ generator = (
107
+ torch.Generator("cuda").manual_seed(seed)
108
+ if seed is not None
109
+ else None
110
+ )
111
+ try:
112
+ # Resize to multiples of 16
113
+ w = _snap_to_multiple(image.width)
114
+ h = _snap_to_multiple(image.height)
115
+ if (w, h) != image.size:
116
+ image = image.resize((w, h), Image.LANCZOS)
117
+
118
+ result = img2img_pipe(
119
+ prompt=prompt,
120
+ image=image,
121
+ strength=strength,
122
+ num_inference_steps=steps,
123
+ generator=generator,
124
+ ).images[0]
125
+ buf = BytesIO()
126
+ result.save(buf, format="PNG")
127
+ buf.seek(0)
128
+ return buf
129
+ finally:
130
+ torch.cuda.empty_cache()
131
+
132
+
133
+ # ── Routes ───────────────────────────────────────────────────────────
134
+
135
+ @app.get("/health")
136
+ async def health():
137
+ return {
138
+ "status": "ok" if pipe is not None else "loading",
139
+ "model": "flux-dev",
140
+ }
141
+
142
+
143
+ @app.post("/generate", dependencies=[Depends(verify_api_key)])
144
+ async def generate(req: GenerateRequest):
145
+ if pipe is None:
146
+ raise HTTPException(status_code=503, detail="Model is still loading")
147
+
148
+ logger.info("text2img: %s", req.prompt[:80])
149
+ buf = await asyncio.to_thread(
150
+ _run_pipeline, req.prompt, req.width, req.height, req.num_inference_steps, req.seed
151
+ )
152
+ logger.info("text2img complete")
153
+ return StreamingResponse(buf, media_type="image/png")
154
+
155
+
156
+ @app.post("/img2img", dependencies=[Depends(verify_api_key)])
157
+ async def img2img(
158
+ image: UploadFile = File(...),
159
+ prompt: str = Form(..., min_length=1, max_length=2000),
160
+ strength: float = Form(default=0.75, ge=0.0, le=1.0),
161
+ num_inference_steps: int = Form(default=25, ge=1, le=50),
162
+ seed: int | None = Form(default=None),
163
+ ):
164
+ if img2img_pipe is None:
165
+ raise HTTPException(status_code=503, detail="Model is still loading")
166
+
167
+ image_bytes = await image.read()
168
+ try:
169
+ pil_image = Image.open(BytesIO(image_bytes)).convert("RGB")
170
+ except Exception:
171
+ raise HTTPException(status_code=400, detail="Invalid image file")
172
+
173
+ logger.info("img2img (strength=%.2f): %s", strength, prompt[:80])
174
+ buf = await asyncio.to_thread(
175
+ _run_img2img_pipeline, pil_image, prompt, strength, num_inference_steps, seed,
176
+ )
177
+ logger.info("img2img complete")
178
+ return StreamingResponse(buf, media_type="image/png")
infra/chat/app/main.py ADDED
@@ -0,0 +1,84 @@
1
+ import os
2
+ import json
3
+ import httpx
4
+ from fastapi import FastAPI, Request
5
+ from fastapi.responses import HTMLResponse, StreamingResponse
6
+ from fastapi.templating import Jinja2Templates
7
+
8
+ app = FastAPI(title="Nyx Chat")
9
+ templates = Jinja2Templates(directory="templates")
10
+
11
+ VLLM_BASE_URL = os.getenv("VLLM_BASE_URL", "http://localhost:8000/v1")
12
+
13
+ SYSTEM_PROMPT = {
14
+ "role": "system",
15
+ "content": (
16
+ "You are Kairo, an AI assistant. "
17
+ "You are powered by the Nyx model. "
18
+ "The Kairo model family includes Nyx (lightweight), Theron (code specialist), and Helios (flagship). "
19
+ "You are NOT GPT, ChatGPT, Claude, Llama, or any other third-party AI. You are Kairo. "
20
+ "Never reveal your underlying architecture or training details. "
21
+ "You are helpful, concise, and knowledgeable."
22
+ ),
23
+ }
24
+
25
+
26
+ @app.get("/", response_class=HTMLResponse)
27
+ async def index(request: Request):
28
+ return templates.TemplateResponse("index.html", {"request": request})
29
+
30
+
31
+ @app.get("/api/health")
32
+ async def health():
33
+ try:
34
+ async with httpx.AsyncClient(timeout=5.0) as client:
35
+ r = await client.get(f"{VLLM_BASE_URL}/models")
36
+ models = r.json()
37
+ return {"status": "ok", "models": models}
38
+ except Exception as e:
39
+ return {"status": "error", "detail": str(e)}
40
+
41
+
42
+ @app.post("/api/chat")
43
+ async def chat(request: Request):
44
+ body = await request.json()
45
+ messages = body.get("messages", [])
46
+ model = body.get("model", "")
47
+ temperature = body.get("temperature", 0.7)
48
+ max_tokens = body.get("max_tokens", 2048)
49
+
50
+ # Prepend system prompt if not already present
51
+ if not messages or messages[0].get("role") != "system":
52
+ messages = [SYSTEM_PROMPT] + messages
53
+
54
+ payload = {
55
+ "model": model,
56
+ "messages": messages,
57
+ "temperature": temperature,
58
+ "max_tokens": max_tokens,
59
+ "stream": True,
60
+ }
61
+
62
+ async def stream_response():
63
+ async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client:
64
+ async with client.stream(
65
+ "POST",
66
+ f"{VLLM_BASE_URL}/chat/completions",
67
+ json=payload,
68
+ ) as response:
69
+ async for line in response.aiter_lines():
70
+ if line.startswith("data: "):
71
+ data = line[6:]
72
+ if data.strip() == "[DONE]":
73
+ yield "data: [DONE]\n\n"
74
+ break
75
+ try:
76
+ chunk = json.loads(data)
77
+ delta = chunk["choices"][0].get("delta", {})
78
+ content = delta.get("content", "")
79
+ if content:
80
+ yield f"data: {json.dumps({'content': content})}\n\n"
81
+ except (json.JSONDecodeError, KeyError, IndexError):
82
+ continue
83
+
84
+ return StreamingResponse(stream_response(), media_type="text/event-stream")
File without changes
File without changes
@@ -0,0 +1,23 @@
1
+ from fastapi import APIRouter, Depends
2
+
3
+ from backend.core.admin_auth import require_role
4
+
5
+ from backend.api.admin.users import router as users_router
6
+ from backend.api.admin.stats import router as stats_router
7
+ from backend.api.admin.system import router as system_router
8
+ from backend.api.admin.content import router as content_router
9
+ from backend.api.admin.audit import router as audit_router
10
+ from backend.api.admin.incidents import router as incidents_router
11
+
12
+ router = APIRouter(
13
+ prefix="/admin",
14
+ tags=["admin"],
15
+ dependencies=[Depends(require_role("admin"))],
16
+ )
17
+
18
+ router.include_router(users_router)
19
+ router.include_router(stats_router)
20
+ router.include_router(system_router)
21
+ router.include_router(content_router)
22
+ router.include_router(audit_router)
23
+ router.include_router(incidents_router)
@@ -0,0 +1,54 @@
1
+ import logging
2
+ from datetime import datetime
3
+
4
+ from fastapi import APIRouter, Depends, Query
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from backend.core.admin_auth import require_role
8
+ from backend.core.database import get_db
9
+ from backend.schemas.admin.audit import AuditLogEntry
10
+ from backend.services.admin.audit_service import AuditService
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter(
15
+ prefix="/audit-logs",
16
+ tags=["admin-audit"],
17
+ dependencies=[Depends(require_role("superadmin"))],
18
+ )
19
+
20
+
21
+ @router.get("")
22
+ async def list_audit_logs(
23
+ admin_id: str | None = Query(None),
24
+ action: str | None = Query(None),
25
+ target_type: str | None = Query(None),
26
+ result: str | None = Query(None),
27
+ date_from: datetime | None = Query(None),
28
+ date_to: datetime | None = Query(None),
29
+ cursor: str | None = Query(None),
30
+ limit: int = Query(50, ge=1, le=100),
31
+ db: AsyncSession = Depends(get_db),
32
+ ):
33
+ filters = {}
34
+ if admin_id:
35
+ filters["admin_id"] = admin_id
36
+ if action:
37
+ filters["action"] = action
38
+ if target_type:
39
+ filters["target_type"] = target_type
40
+ if result:
41
+ filters["result"] = result
42
+ if date_from:
43
+ filters["date_from"] = date_from
44
+ if date_to:
45
+ filters["date_to"] = date_to
46
+
47
+ svc = AuditService(db)
48
+ logs = await svc.list_logs(filters=filters or None, cursor=cursor, limit=limit + 1)
49
+ has_more = len(logs) > limit
50
+ if has_more:
51
+ logs = logs[:limit]
52
+ items = [AuditLogEntry.model_validate(log) for log in logs]
53
+ next_cursor = logs[-1].id if has_more and logs else None
54
+ return {"items": items, "next_cursor": next_cursor}
@@ -0,0 +1,142 @@
1
+ import logging
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from backend.core.admin_auth import require_role
7
+ from backend.core.database import get_db
8
+ from backend.models.user import User
9
+ from backend.schemas.admin.content import (
10
+ AdminConversationDetail,
11
+ AdminConversationListItem,
12
+ AdminImageListItem,
13
+ ContentDeleteRequest,
14
+ )
15
+ from backend.services.admin.audit_service import AuditService
16
+ from backend.services.admin.content_service import AdminContentService
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter(
21
+ tags=["admin-content"],
22
+ dependencies=[Depends(require_role("moderator"))],
23
+ )
24
+
25
+
26
+ def _get_ip(request: Request) -> str:
27
+ return request.client.host if request.client else "unknown"
28
+
29
+
30
+ def _get_ua(request: Request) -> str:
31
+ return request.headers.get("user-agent", "")
32
+
33
+
34
+ @router.get("/conversations")
35
+ async def list_conversations(
36
+ search: str | None = Query(None),
37
+ user_id: str | None = Query(None),
38
+ cursor: str | None = Query(None),
39
+ limit: int = Query(25, ge=1, le=100),
40
+ db: AsyncSession = Depends(get_db),
41
+ ):
42
+ svc = AdminContentService(db)
43
+ data = await svc.list_conversations(search=search, user_id=user_id, cursor=cursor, limit=limit + 1)
44
+ has_more = len(data) > limit
45
+ if has_more:
46
+ data = data[:limit]
47
+ items = [AdminConversationListItem(**item) for item in data]
48
+ next_cursor = data[-1]["id"] if has_more and data else None
49
+ return {"items": items, "next_cursor": next_cursor}
50
+
51
+
52
+ @router.get("/conversations/{conversation_id}", response_model=AdminConversationDetail)
53
+ async def get_conversation_detail(
54
+ conversation_id: str,
55
+ request: Request,
56
+ db: AsyncSession = Depends(get_db),
57
+ admin: User = Depends(require_role("moderator")),
58
+ ):
59
+ svc = AdminContentService(db)
60
+ data = await svc.get_conversation_detail(conversation_id)
61
+ if not data:
62
+ raise HTTPException(status_code=404, detail="Conversation not found")
63
+
64
+ audit = AuditService(db)
65
+ await audit.log(
66
+ admin_user_id=admin.id,
67
+ action="content.conversation_viewed",
68
+ target_type="conversation",
69
+ target_id=conversation_id,
70
+ ip_address=_get_ip(request),
71
+ user_agent=_get_ua(request),
72
+ )
73
+ return AdminConversationDetail(**data)
74
+
75
+
76
+ @router.delete("/conversations/{conversation_id}")
77
+ async def delete_conversation(
78
+ conversation_id: str,
79
+ body: ContentDeleteRequest,
80
+ request: Request,
81
+ db: AsyncSession = Depends(get_db),
82
+ admin: User = Depends(require_role("moderator")),
83
+ ):
84
+ svc = AdminContentService(db)
85
+ deleted = await svc.delete_conversation(conversation_id)
86
+ if not deleted:
87
+ raise HTTPException(status_code=404, detail="Conversation not found")
88
+
89
+ audit = AuditService(db)
90
+ await audit.log(
91
+ admin_user_id=admin.id,
92
+ action="content.conversation_deleted",
93
+ target_type="conversation",
94
+ target_id=conversation_id,
95
+ details={"reason": body.reason},
96
+ ip_address=_get_ip(request),
97
+ user_agent=_get_ua(request),
98
+ )
99
+ return {"message": "Conversation deleted"}
100
+
101
+
102
+ @router.get("/images/recent")
103
+ async def list_recent_images(
104
+ user_id: str | None = Query(None),
105
+ cursor: str | None = Query(None),
106
+ limit: int = Query(50, ge=1, le=100),
107
+ db: AsyncSession = Depends(get_db),
108
+ ):
109
+ svc = AdminContentService(db)
110
+ images = await svc.list_recent_images(user_id=user_id, cursor=cursor, limit=limit + 1)
111
+ has_more = len(images) > limit
112
+ if has_more:
113
+ images = images[:limit]
114
+ items = [AdminImageListItem.model_validate(img) for img in images]
115
+ next_cursor = images[-1].id if has_more and images else None
116
+ return {"items": items, "next_cursor": next_cursor}
117
+
118
+
119
+ @router.delete("/images/{image_id}")
120
+ async def delete_image(
121
+ image_id: str,
122
+ body: ContentDeleteRequest,
123
+ request: Request,
124
+ db: AsyncSession = Depends(get_db),
125
+ admin: User = Depends(require_role("moderator")),
126
+ ):
127
+ svc = AdminContentService(db)
128
+ deleted = await svc.delete_image(image_id)
129
+ if not deleted:
130
+ raise HTTPException(status_code=404, detail="Image not found")
131
+
132
+ audit = AuditService(db)
133
+ await audit.log(
134
+ admin_user_id=admin.id,
135
+ action="content.image_deleted",
136
+ target_type="image",
137
+ target_id=image_id,
138
+ details={"reason": body.reason},
139
+ ip_address=_get_ip(request),
140
+ user_agent=_get_ua(request),
141
+ )
142
+ return {"message": "Image deleted"}
@@ -0,0 +1,148 @@
1
+ import logging
2
+ from datetime import datetime, UTC
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from backend.core.admin_auth import require_role
8
+ from backend.core.database import get_db
9
+ from backend.models.user import User
10
+ from backend.schemas.status import (
11
+ CreateIncidentRequest,
12
+ IncidentSummary,
13
+ UpdateIncidentRequest,
14
+ )
15
+ from backend.services.admin.audit_service import AuditService
16
+ from backend.services.admin.incident_service import IncidentService
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter(prefix="/incidents", tags=["admin-incidents"])
21
+
22
+
23
+ def _incident_to_summary(incident) -> dict:
24
+ return {
25
+ "id": incident.id,
26
+ "title": incident.title,
27
+ "description": incident.description,
28
+ "severity": incident.severity,
29
+ "component": incident.component,
30
+ "status": incident.status,
31
+ "started_at": incident.started_at.isoformat(),
32
+ "resolved_at": incident.resolved_at.isoformat() if incident.resolved_at else None,
33
+ }
34
+
35
+
36
+ @router.post("", response_model=IncidentSummary)
37
+ async def create_incident(
38
+ body: CreateIncidentRequest,
39
+ request: Request,
40
+ db: AsyncSession = Depends(get_db),
41
+ admin: User = Depends(require_role("admin")),
42
+ ):
43
+ """Create a new incident."""
44
+ svc = IncidentService(db)
45
+ incident = await svc.create_incident(
46
+ title=body.title,
47
+ description=body.description,
48
+ severity=body.severity,
49
+ component=body.component,
50
+ )
51
+
52
+ # Audit log
53
+ audit = AuditService(db)
54
+ ip = request.client.host if request.client else "unknown"
55
+ ua = request.headers.get("user-agent", "")
56
+ await audit.log(
57
+ admin_user_id=admin.id,
58
+ action="incident.created",
59
+ target_type="incident",
60
+ target_id=incident.id,
61
+ details={"title": body.title, "component": body.component, "severity": body.severity},
62
+ ip_address=ip,
63
+ user_agent=ua,
64
+ )
65
+
66
+ return IncidentSummary(**_incident_to_summary(incident))
67
+
68
+
69
+ @router.get("", response_model=list[IncidentSummary])
70
+ async def list_incidents(
71
+ limit: int = Query(default=50, le=200),
72
+ include_resolved: bool = Query(default=False),
73
+ db: AsyncSession = Depends(get_db),
74
+ ):
75
+ """List incidents. Defaults to active (unresolved) incidents only."""
76
+ svc = IncidentService(db)
77
+ incidents = await svc.list_incidents(limit=limit, include_resolved=include_resolved)
78
+ return [IncidentSummary(**_incident_to_summary(i)) for i in incidents]
79
+
80
+
81
+ @router.patch("/{incident_id}", response_model=IncidentSummary)
82
+ async def update_incident(
83
+ incident_id: str,
84
+ body: UpdateIncidentRequest,
85
+ request: Request,
86
+ db: AsyncSession = Depends(get_db),
87
+ admin: User = Depends(require_role("admin")),
88
+ ):
89
+ """Update an incident's fields."""
90
+ svc = IncidentService(db)
91
+
92
+ update_data = body.model_dump(exclude_unset=True)
93
+ # Parse resolved_at if provided as ISO string
94
+ if "resolved_at" in update_data and update_data["resolved_at"] is not None:
95
+ try:
96
+ update_data["resolved_at"] = datetime.fromisoformat(update_data["resolved_at"])
97
+ except ValueError:
98
+ raise HTTPException(status_code=400, detail="Invalid resolved_at format. Use ISO 8601.")
99
+
100
+ incident = await svc.update_incident(incident_id, **update_data)
101
+ if not incident:
102
+ raise HTTPException(status_code=404, detail="Incident not found")
103
+
104
+ # Audit log
105
+ audit = AuditService(db)
106
+ ip = request.client.host if request.client else "unknown"
107
+ ua = request.headers.get("user-agent", "")
108
+ await audit.log(
109
+ admin_user_id=admin.id,
110
+ action="incident.updated",
111
+ target_type="incident",
112
+ target_id=incident_id,
113
+ details=update_data,
114
+ ip_address=ip,
115
+ user_agent=ua,
116
+ )
117
+
118
+ return IncidentSummary(**_incident_to_summary(incident))
119
+
120
+
121
+ @router.post("/{incident_id}/resolve", response_model=IncidentSummary)
122
+ async def resolve_incident(
123
+ incident_id: str,
124
+ request: Request,
125
+ db: AsyncSession = Depends(get_db),
126
+ admin: User = Depends(require_role("admin")),
127
+ ):
128
+ """Mark an incident as resolved."""
129
+ svc = IncidentService(db)
130
+ incident = await svc.resolve_incident(incident_id)
131
+ if not incident:
132
+ raise HTTPException(status_code=404, detail="Incident not found")
133
+
134
+ # Audit log
135
+ audit = AuditService(db)
136
+ ip = request.client.host if request.client else "unknown"
137
+ ua = request.headers.get("user-agent", "")
138
+ await audit.log(
139
+ admin_user_id=admin.id,
140
+ action="incident.resolved",
141
+ target_type="incident",
142
+ target_id=incident_id,
143
+ details={},
144
+ ip_address=ip,
145
+ user_agent=ua,
146
+ )
147
+
148
+ return IncidentSummary(**_incident_to_summary(incident))